Skip to content

fix(bootstrap): reject on non-2xx tarball response and handle zlib errors - #356

Open
cs-raj wants to merge 5 commits into
developmentfrom
fix/DX-10257
Open

fix(bootstrap): reject on non-2xx tarball response and handle zlib errors#356
cs-raj wants to merge 5 commits into
developmentfrom
fix/DX-10257

Conversation

@cs-raj

@cs-raj cs-raj commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Problem

csdx cm:bootstrap crashed with an unhandled Z_DATA_ERROR (incorrect header check) when cloning the Kickstart Next.js starter app. Two bugs combined to cause this:

  1. streamRelease() did not check the HTTP response status. When the cli-use branch was absent from contentstack/kickstart-next, codeload.github.com returned a 404: Not Found body. That body stream was silently passed downstream as if it were a valid tarball.

  2. extract() had no error handler on the zlib.createUnzip() stream. Node's pipe() does not forward stream errors between stages. When zlib tried to decompress the "404: Not Found" bytes (which have no gzip magic header), it emitted an error event on the Unzip instance with no listener — causing an unhandled exception that crashed the process instead of rejecting the Promise cleanly.

Relates to: DX-10257

Fix

  • streamRelease() — throws GithubError with the actual HTTP status code for any 4xx/5xx response. The existing Bootstrap.run() catch block already handles GithubError with status === 404 and prints a user-friendly "Unable to find a repo" message; no caller changes needed.

  • extract() — extracts the zlib.createUnzip() instance and attaches .on('error', reject) directly to it, so zlib errors reject the Promise rather than escaping as unhandled events.

Test plan

  • 6 new unit tests added to packages/contentstack-bootstrap/test/github.test.js
    • streamRelease throws GithubError(404) on a 404 response
    • streamRelease throws GithubError(500) on a 500 response
    • streamRelease returns the data stream on a 200 response
    • streamRelease sends Authorization header for private repos
    • streamRelease throws immediately for private repos with no token
    • extract rejects with Z_DATA_ERROR (not a process crash) on invalid gzip data
  • All 71 existing tests continue to pass
  • csdx cm:bootstrap → Kickstart Next.js ran end-to-end successfully after the missing cli-use branch was created on the repo

🤖 Generated with Claude Code

…rors

streamRelease now throws GithubError for HTTP 4xx/5xx responses instead
of silently piping the error body (e.g. "404: Not Found") into the zlib
decompressor. This was the root cause of the Z_DATA_ERROR crash when the
cli-use branch was absent from a repo.

extract now attaches an error handler directly on the zlib.createUnzip()
stream. Node's pipe() does not forward stream errors, so without this
listener a zlib failure emitted an unhandled error event and crashed the
process rather than rejecting the Promise cleanly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@cs-raj
cs-raj requested a review from a team as a code owner August 20, 2026 11:31
@snyk-io

snyk-io Bot commented Aug 20, 2026

Copy link
Copy Markdown

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues
Code Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@github-actions

Copy link
Copy Markdown

🔒 Security Scan Results

ℹ️ Note: Only vulnerabilities with available fixes (upgrades or patches) are counted toward thresholds.

Check Type Count (with fixes) Without fixes Threshold Result
🔴 Critical Severity 0 0 10 ✅ Passed
🟠 High Severity 0 0 25 ✅ Passed
🟡 Medium Severity 0 0 500 ✅ Passed
🔵 Low Severity 0 0 1000 ✅ Passed

⏱️ SLA Breach Summary

✅ No SLA breaches detected. All vulnerabilities are within acceptable time thresholds.

Severity Breaches (with fixes) Breaches (no fixes) SLA Threshold (with/no fixes) Status
🔴 Critical 0 0 15 / 30 days ✅ Passed
🟠 High 0 0 30 / 120 days ✅ Passed
🟡 Medium 0 0 90 / 365 days ✅ Passed
🔵 Low 0 0 180 / 365 days ✅ Passed

✅ BUILD PASSED - All security checks passed

Moving cliux.loader() (spinner stop) out of finally and into catch before
cliux.error() prevents the spinner's carriage-return from wiping the error
line. Success path stops the spinner inline after getLatest resolves.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

🔒 Security Scan Results

ℹ️ Note: Only vulnerabilities with available fixes (upgrades or patches) are counted toward thresholds.

Check Type Count (with fixes) Without fixes Threshold Result
🔴 Critical Severity 0 0 10 ✅ Passed
🟠 High Severity 0 0 25 ✅ Passed
🟡 Medium Severity 0 0 500 ✅ Passed
🔵 Low Severity 0 0 1000 ✅ Passed

⏱️ SLA Breach Summary

✅ No SLA breaches detected. All vulnerabilities are within acceptable time thresholds.

Severity Breaches (with fixes) Breaches (no fixes) SLA Threshold (with/no fixes) Status
🔴 Critical 0 0 15 / 30 days ✅ Passed
🟠 High 0 0 30 / 120 days ✅ Passed
🟡 Medium 0 0 90 / 365 days ✅ Passed
🔵 Low 0 0 180 / 365 days ✅ Passed

✅ BUILD PASSED - All security checks passed

Replace the generic cliux.error+rethrow pattern with a single clean
Error throw so oclif prints one message. Message names both the repo
and the missing cli-use branch so the developer knows exactly what to
check on GitHub.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

🔒 Security Scan Results

ℹ️ Note: Only vulnerabilities with available fixes (upgrades or patches) are counted toward thresholds.

Check Type Count (with fixes) Without fixes Threshold Result
🔴 Critical Severity 0 0 10 ✅ Passed
🟠 High Severity 0 0 25 ✅ Passed
🟡 Medium Severity 0 0 500 ✅ Passed
🔵 Low Severity 0 0 1000 ✅ Passed

⏱️ SLA Breach Summary

✅ No SLA breaches detected. All vulnerabilities are within acceptable time thresholds.

Severity Breaches (with fixes) Breaches (no fixes) SLA Threshold (with/no fixes) Status
🔴 Critical 0 0 15 / 30 days ✅ Passed
🟠 High 0 0 30 / 120 days ✅ Passed
🟡 Medium 0 0 90 / 365 days ✅ Passed
🔵 Low 0 0 180 / 365 days ✅ Passed

✅ BUILD PASSED - All security checks passed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens the contentstack-bootstrap plugin’s GitHub tarball download + extraction path so csdx cm:bootstrap fails gracefully (rejects promises) instead of crashing on invalid gzip data returned from failed GitHub responses.

Changes:

  • Add HTTP status validation to streamRelease() so error responses aren’t treated as tarball streams.
  • Attach an error handler to the unzip stream in extract() and add unit tests covering these failure modes.
  • Update bootstrap error messaging for missing/unavailable app downloads.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/contentstack-bootstrap/test/github.test.js Adds unit tests for streamRelease() status handling and extract() invalid gzip rejection.
packages/contentstack-bootstrap/src/bootstrap/index.ts Adjusts loader lifecycle and maps GitHub 404s to a user-facing “app unavailable” error.
packages/contentstack-bootstrap/src/bootstrap/github/client.ts Adds response status checking before returning the tarball stream; adds unzip error handling.
packages/contentstack-bootstrap/messages/index.json Introduces a new user-facing message for app download unavailability.
.talismanrc Updates checksums / ignore entries (incl. newly added test file).
Suppressed comments (1)

packages/contentstack-bootstrap/src/bootstrap/github/client.ts:93

  • extract() now listens for unzip errors, but the source stream can still emit an error event with no listener (Node treats that as an unhandled exception). Attach an error handler to the input stream so network/IO failures reject the Promise instead of crashing.
    return new Promise((resolve, reject) => {
      const unzip = zlib.createUnzip();
      unzip.on('error', reject);
      stream
        .pipe(unzip)

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/contentstack-bootstrap/src/bootstrap/github/client.ts Outdated
Comment thread packages/contentstack-bootstrap/src/bootstrap/index.ts
Comment thread packages/contentstack-bootstrap/messages/index.json
- Add stream.on('error', reject) to handle network/IO failures on the
  source stream, not just zlib decompression errors
- Use distinct error message for non-404 HTTP failures (5xx, 403, etc.)
  so users aren't told "repo not found" when it's a server/auth error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

🔒 Security Scan Results

ℹ️ Note: Only vulnerabilities with available fixes (upgrades or patches) are counted toward thresholds.

Check Type Count (with fixes) Without fixes Threshold Result
🔴 Critical Severity 0 0 10 ✅ Passed
🟠 High Severity 0 0 25 ✅ Passed
🟡 Medium Severity 0 0 500 ✅ Passed
🔵 Low Severity 0 0 1000 ✅ Passed

⏱️ SLA Breach Summary

✅ No SLA breaches detected. All vulnerabilities are within acceptable time thresholds.

Severity Breaches (with fixes) Breaches (no fixes) SLA Threshold (with/no fixes) Status
🔴 Critical 0 0 15 / 30 days ✅ Passed
🟠 High 0 0 30 / 120 days ✅ Passed
🟡 Medium 0 0 90 / 365 days ✅ Passed
🔵 Low 0 0 180 / 365 days ✅ Passed

✅ BUILD PASSED - All security checks passed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Suppressed comments (2)

Previously missed (2) — in code that hasn't changed since the last review.

packages/contentstack-bootstrap/test/github.test.js:2

  • github.test.js now requires sinon, but packages/contentstack-bootstrap/package.json does not declare it in devDependencies. This makes the test suite depend on workspace hoisting (e.g. shamefully-hoist) and can break if hoisting settings change.
const sinon = require('sinon');

packages/contentstack-bootstrap/test/github.test.js:93

  • Avoid using a token-like literal in tests if it triggers secret-scanner false positives. Using a clearly dummy value also makes it easier to remove the .talismanrc allowlist entry for this file.
      const client = new GitHubClient(GitHubClient.parsePath('contentstack/private-repo'), true, 'my-token');
      await client.streamRelease(client.gitTarBallUrl);

      const callOptions = httpStub.options.firstCall.args[0];
      expect(callOptions.headers).to.deep.equal({ Authorization: 'token my-token' });

Comment thread .talismanrc
Comment on lines +42 to +43
- filename: packages/contentstack-bootstrap/test/github.test.js
checksum: b7badfcd3bbad0cb876364542bba26cdfd854f1b138be2896b5f84c219767040

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The false positive is unavoidable here — the test asserts that the exact string 'Authorization' is used as the header key, which is the correct and required HTTP header name. Removing or obfuscating it would reduce test fidelity. The talismanrc entry is scoped to this specific file with a checksum, so any future edits to the file will force a new checksum update and re-review. Risk is low and accepted.

"CLI_BOOTSTRAP_GITHUB_ACCESS_NOT_FOUND": "No Github access token found",
"CLI_BOOTSTRAP_START_CLONE_APP": "Cloning the selected app",
"CLI_BOOTSTRAP_REPO_NOT_FOUND": "Unable to find a repo for \"%s\"",
"CLI_BOOTSTRAP_APP_UNAVAILABLE": "Unable to download \"%s\": branch \"cli-use\" not found. Ensure the branch exists on the GitHub repository.",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 28f9ef3. Widened the message to: "Unable to download '%s': the repository or branch 'cli-use' was not found. Ensure both exist on GitHub." — accurate for both a missing branch and a missing/inaccessible repo.

Comment on lines +78 to +82
if (response.status >= 400) {
const message = response.status === 404
? messageHandler.parse('CLI_BOOTSTRAP_REPO_NOT_FOUND', `${this.repo.user}/${this.repo.name}`)
: messageHandler.parse('CLI_BOOTSTRAP_GITHUB_SERVER_ERROR', `${this.repo.user}/${this.repo.name}`, response.status);
throw new GithubError(message, response.status);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 28f9ef3. Changed the check to response.status < 200 || response.status >= 400 so any non-2xx response — including unexpected 3xx that might slip past axios's redirect handling — is rejected rather than treated as a valid tarball stream.

- Widen CLI_BOOTSTRAP_APP_UNAVAILABLE to cover both repo and branch
  missing, not just branch, since GitHub returns 404 for both cases
- Change status check from >= 400 to < 200 || >= 400 so unexpected
  non-2xx responses (e.g. stray 3xx) are also rejected as invalid

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@cs-raj
cs-raj requested a lite review from Copilot August 24, 2026 17:41
@github-actions

Copy link
Copy Markdown

🔒 Security Scan Results

ℹ️ Note: Only vulnerabilities with available fixes (upgrades or patches) are counted toward thresholds.

Check Type Count (with fixes) Without fixes Threshold Result
🔴 Critical Severity 0 0 10 ✅ Passed
🟠 High Severity 0 0 25 ✅ Passed
🟡 Medium Severity 0 0 500 ✅ Passed
🔵 Low Severity 0 0 1000 ✅ Passed

⏱️ SLA Breach Summary

✅ No SLA breaches detected. All vulnerabilities are within acceptable time thresholds.

Severity Breaches (with fixes) Breaches (no fixes) SLA Threshold (with/no fixes) Status
🔴 Critical 0 0 15 / 30 days ✅ Passed
🟠 High 0 0 30 / 120 days ✅ Passed
🟡 Medium 0 0 90 / 365 days ✅ Passed
🔵 Low 0 0 180 / 365 days ✅ Passed

✅ BUILD PASSED - All security checks passed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.


const response = await HttpClient.create().options(options).get(url);

if (response.status < 200 || response.status >= 400) {
Comment on lines 72 to 79
try {
await this.ghClient.getLatest(this.cloneDirectory);
cliux.loader();
} catch (error) {
if (error instanceof GithubError) {
if (error.status === 404) {
cliux.error(messageHandler.parse('CLI_BOOTSTRAP_REPO_NOT_FOUND', this.appConfig.source));
}
cliux.loader();
if (error instanceof GithubError && error.status === 404) {
throw new Error(messageHandler.parse('CLI_BOOTSTRAP_APP_UNAVAILABLE', this.appConfig.source));
}
Comment on lines +73 to +82
it('should return the response stream when status is 200', async () => {
const mockStream = new Readable({ read() {} });
const httpStub = { get: sandbox.stub().resolves({ status: 200, data: mockStream }), options: sandbox.stub().returnsThis() };
sandbox.stub(HttpClient, 'create').returns(httpStub);

const client = new GitHubClient(GitHubClient.parsePath('contentstack/kickstart-next'));
const result = await client.streamRelease(client.gitTarBallUrl);

expect(result).to.equal(mockStream);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants